組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

5.2 行間を読もう

C++では,オブジェクトの生成・解体をはじめ,さまざまな処理が暗黙的に実行されます.それは,コーディング時の記述量を減らすことで生産性の向上に繋がるほか,リソースの解放忘れのような単純なミスによるバグを減少させる効果があります.一方で,ソースコードには現れない処理をコンパイラが勝手に挿入するということでもあるため,ソースコードを読む際には,どうしても行間(あるいは字句間)を読む必要が出てきます.

これはなにもC++に特有のことではありません.Cの場合も,関数の呼び出し時にはスタックフレームの操作が勝手に挿入されることになりますし,短絡評価を行う||や&&といった演算子を使えば勝手に条件分岐命令が挿入されることになります.演算の際には「通常の算術型変換」のような暗黙的な型変換の処理も挿入されます.つまり,プログラミング言語が高級になればなるほど,プログラムの正確な動作を理解するためには,行間を読むことが不可欠になるのです.

5.2.1 行間や字句間で何が起きるのか?

行間を読む必要があると書くと,C++ではソースコードを見ただけでは何が起きるのか見当も付かないのではないかと考え,必要以上に不安になる方もいらっしゃるでしょう.そこで,行間や字句間に勝手に挿入される処理にどんなものがあるのかを最初に把握しておくことにしましょう.行間や字句間に勝手に挿入される処理は次の3点です.

  • (A) オブジェクトの生成と解体
  • (B) 例外の送出
  • (C) 不正な例外の処理

このうち,(C)に遭遇する機会はそれほど多くりません.詳細については「3.8 例外指定の振る舞い」を参照してください.ここでは(A)と(B)に絞って解説を行うことにします.

オブジェクトの生成と解体

(A)はいうまでもなく,オブジェクトのコンストラクタとデストラクタの呼び出しにかかわるものです.オブジェクトを宣言したときにコンストラクタが呼び出され,生存期間を終えるときにデストラクタが自動的に呼び出されます.通常どおりブロックから抜ける場合にデストラクタが呼び出されるのはすぐに理解できるかと思いますが,次のような場合にもデストラクタが呼び出されます.

class A
{
public:
    ~A();
    bool func();
};
int foo(int arg)
{
    int result = 0;
    if (arg > 0)
    {
        A a;
        if (a.func())
            goto label;  // ← gotoで抜けてもA::~A()が呼び出される 
            result = 1;
    }
label:
    return result;
}

ちょっとわざとらしい例ですが,このサンプルで指摘したいのは,goto文を使ってブロックから抜けた場合でも,デストラクタが呼び出されるということです.return文を使ってブロックから抜け出した場合でも,break文やcontinue文によってブロックから抜け出した場合でも,例外によってブロックから抜け出した場合でも,やはり同じようにデストラクタが呼び出されます.

また,オブジェクトの生成と解体は,このように明示的に宣言を行った場合だけに起こるのではありません.次の例をご覧ください.

#include <string>
int main()
{
    std::string s1("ABC");
    std::string s2;
    s2 = s1 + "DEF" + "GHI";
    return 0;
}

この例では,文字列クラス(std::string)が登場しています.組込み開発でstd::stringを使う機会が多いかどうかは議論の本質ではありません.文字列s1は"ABC"という値で初期化しています.そして,"DEF"と"GHI"を連結した"ABCDEFGHI"という文字列をs2に代入しています.std::stringのことをよく知らなくても,何をしようとしているかは直感的に理解できることでしょう.

ここで,「s1 + "DEF"」という演算を行った時点で,"ABCDEF"という値を持つ一時オブジェクトが生成されます.仮にこの一時オブジェクトをtempと呼ぶことにしましょう.そして,「temp + "GHI"」が実行されたときにも同じように,"ABCDEFGHI"という値を持つ一時オブジェクトが生成されます.

// 内部の動作を表すコード
    std::string s1("ABC");
    std::string s2;
    {
        std::string temp = s1;
        temp += "DEF";
        std::string temp2 = temp;
        temp2 += "GHI";
        s2 = temp2;
        // ← ここで,tempとtemp2が解体される 
    }

std::stringは,文字列を格納するためのメモリをオブジェクトの生成時に動的に割り付けています.そのため,オブジェクトの生成と解体は必ずしも軽量とはいえません.「3.10.4 一時オブジェクト」でも解説しましたが,このような+演算子というなんでもないコードの前後に,かなり大きな処理が挿入されることになるわけです.また,動的にメモリを割り付けるということは,当然失敗する可能性がありますから,例外が送出される可能性も出てくるわけです.仮に例外が送出されないとしても,例外が送出された場合にデストラクタを呼び出すための処理も勝手に挿入されることになります.

例外の送出

(B)の例外の送出については,C++ではありとあらゆる箇所で例外が送出される可能性があります.例外が送出される可能性があれば,実際に送出されるかどうかはともかく,それに備えるための処理が勝手に挿入されます.例外が送出されないのは,組込み型の操作を行うときと*1,明示的に例外指定throw()を付けて宣言された関数や演算子を呼び出すときだけなのです.

例外の送出まで考えると,C++の実行パスは非常に複雑になります.たとえば,先ほどの文字列を連結するコードの実行パスについて考えてみましょう.

s2 = s1 + "DEF" + "GHI";
   ▲   ▲      ▲

上で,「▲」が付いた部分が例外が送出されるかもしれない箇所です.符号無し整数の単純な加算であれば,実行パスは1つしかありませんが,この例では,それに加えて,例外によって中断される実行パスが3つもあることになります.

このように,例外に関して勝手に挿入される処理を読み取るには少々慣れが必要です.もし,C++の経験が浅く,不安が残るようであれば,可能なら,利便性や,C++による既存資産の再利用をあきらめてでも,例外処理を抑止するコンパイルオプションを適用するほうが無難でしょう.

*1 数値型どうしの加減乗除であっても,オーバーフローやゼロ除算が発生した場合には,C++の例外ではなく,CPU例外やシグナルが発生することがあります.これはCでも同じです.